Refuse a governed enqueue with no head-pinned approval, in a PreToolUse hook - #14140
Merged
Merged
Conversation
…n a PreToolUse hook A governed PR was enqueued with zero approvals; the merge-queue guard refused as designed, the entry popped out red, and the approval that arrived afterwards did NOT re-run it. One queue cycle burned and a false red shown to the maintainer. The queue guard stays the hard correctness line. This hook removes the wasted cycle at the only place a machine can: our own tool-call surface. It intercepts the enqueue-class calls, asks the two existing single sources -- the register's `--test` predicate and `pinnedApprovalVerdict`/`GOVERNED_APPROVERS` -- and refuses when the diff is governed and unpinned. No second mechanism, no second path list, no second approver list. Pure regeneration clears untouched. Fail-open on any unanswerable read, by design: the correctness line is elsewhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2
…verned-enqueue-precondition
… not a copied verdict The first revision hard-coded `expect allow` against the one exception row that was cheap to lift. That row was retired upstream the same day -- its surface left the governed fence entirely -- and the case went red over a register change the hook had nothing to do with. A verdict copied out of the register makes the matrix a second register. It now asks the register and requires the hook to answer the same way, over the incident's own file class: four `skills/*/references/_index.md`. Measured both branches -- fail-closed with no toolchain (block, agreed) and lifted with the toolchain present (allow with zero reviews, agreed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2
os-zhuang
approved these changes
Sep 1, 2026
os-zhuang
marked this pull request as ready for review
September 1, 2026 07:33
os-zhuang
enabled auto-merge
September 1, 2026 07:33
Contributor
Author
Live self-demonstration: the guard refuses this PR's own enqueueRun against the live API, no fixtures, at head The governed hits and the head sha are read live; the approver set is the imported Generated by Claude Code Generated by Claude Code |
os-zhuang
deleted the
claude/issue-14059-governed-enqueue-precondition
branch
September 1, 2026 08:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #14059
Part of, notFixes: the card asks for two things and this PR lands one ofthem completely. The machine half — a PreToolUse hook that refuses an
enqueue-class tool call on a governed PR with no head-pinned approval — is here.
The
landing-operations.mdannotation is STOPPED on a measured ceiling, withbyte-exact floors below; that half routes to the decision box rather than being
funded by deleting live operational text.
The incident, in one paragraph
A governed PR (four
skills/*/references/_index.md) was flipped ready andenqueued with zero approvals. The merge-queue guard read the reviews on the
merge_groupbuild and refused exactly as designed, so nothing wrong landed —but the queue entry popped out red, and the approval the maintainer pinned to
head
b25f061c6at 01:53:29Z did not re-run it. GitHub's merge queue doesnot retry a failed entry when an approval arrives afterwards. Cost: one burned
queue cycle, plus a red entry that reads to the maintainer as "I reviewed it and
it still blocked me".
So "enqueue first, get approved after" is not a slower route to the same place.
It is a route that always costs a cycle and always manufactures a false red.
Reconciling the triage note with this hook
The card quotes the queue guard's sibling design note: the enqueue threshold
「入队门槛机器上拦不住是 GitHub 模型限制」. That statement is true, and this PR
does not contradict it — it locates it:
between a seat's enqueue call and the queue. Nothing here changes that, and
the red light stays deliberately placed on the
merge_groupanomaly.a tool call this process makes, and PreToolUse sits in front of it. That is
a client-side control, and it is the only place the wasted cycle can be saved.
⇒ The queue guard remains the hard correctness line. This hook is a
cost control, not a correctness one. It can only ever refuse what the queue
guard would also have refused, because it asks the same two predicates.
The triage note's conclusion 「为什么这里只能靠纪律」 is therefore superseded on
the client side only. Nothing about the server-side reasoning changes.
No second mechanism
The hook contains no path list and no approver list of its own. It asks the
two existing single sources and reports what they answer:
scripts/pm/check-governed-merges.mjs--test --json— exit 3 governed, exit 0 clearscripts/pm/check-governed-queue-guard.mjspinnedApprovalVerdict+GOVERNED_APPROVERS, importedA register row or a ruling that moves either predicate reaches this guard for
free, and the two tools cannot answer differently about the same diff. The
self-test asserts this structurally: it fails if an approver login or a
governed-path literal ever appears in the hook's code.
The pure-regeneration class passes, and that is measured
Maintainer ruling 2026-09-01, verbatim and untranslated:
Because the governed question is answered by the register's own
--test, theexemption is inherited whole: a pure regeneration comes back exit 0 and is
ALLOWED before a single review is read. This guard must never be the thing
that re-closes a path the queue guard now clears.
Measured both ways on the incident's own file class (four
skills/*/references/_index.md, zero reviews):--testexit 0, 1.2s) ⇒ hook ALLOWS.--testexit 3) ⇒ hook REFUSES,agreeing with the register rather than overriding it either way.
The self-test pins this as an agreement assertion, not a copied verdict —
see "What went wrong once" below.
The two feasibility premises, MEASURED
The 方向修订 named both as things to measure rather than assume. Both were
measured in this container class on 2026-09-01.
① The API read path. VERBATIM MEASUREMENT:
command -v gh→ absent. There is noghin this container class, so thegh pr mergespelling is defensive portability, not the live path here.GET /repos/OWNER/REPO/pulls/N,.../filesand.../reviewseach answered HTTP 200, and answered 200again from a grandchild subprocess two levels down, which is the depth a
hook runs at. Reachable for the sibling repo too:
objectuiPR reads answered200 (a nonexistent number answered 404, which is the control).
fetch.GITHUB_TOKENhere is the 14-byteplaceholder
proxy-injected; the real credential is swapped in on the wire bythe agent proxy. curl honours
HTTPS_PROXYand gets 200. Node'sfetchdoes not proxy by default, sends the placeholder verbatim, and gets 401 —
measured side by side, same URL, same second. So the transport in the hook is
curl, and node only ever receives already-fetched JSON as data. (The queue
guard's own
fetch-based readers are fine where they run: CI holds a realtoken.)
owner/repo/pullNumberfor the MCP tools, and for Bash from the command text (gh pr mergewith a number or a PR URL,gh apiandcurlagainst a/pulls/N/mergeURL).gh pr mergewith no PR argument names no PR and isallowed with a stated warning — the parse-confidently-or-allow line.
⇒ Premise ① holds. The hook half was NOT stopped.
② Fail-open on read failure. Implemented as the chosen semantic, and the
choice is stated where it can be re-examined: every branch the hook cannot
ANSWER — no token, an HTTP error, a truncated file list, an unparsable payload,
a predicate that will not run, an exception row with no checkout of the target
repo to recompute on — ALLOWS, with one warning line on stderr naming what could
not be read.
That is affordable for exactly one reason, and the header says so: the
correctness line is elsewhere. An unapproved governed PR that slips past this
hook still meets the queue guard on the
merge_groupbuild and is stillrefused. Note this is the OPPOSITE of the queue guard's stance, deliberately —
that one is the last thing before
mainand fails closed; this one sits infront of every enqueue attempt with a network dependency, and a guard that
blocks work whenever the API hiccups gets switched off, and then it guards
nothing.
Decision table
Every row is a self-test case in
.claude/hooks/guard-governed-enqueue.selftest.sh(49 cases, 0 failures).enable_pr_auto_merge on a governed PR with zero reviews·merge_pull_request on a governed PR with zero reviewsgoverned + os-zhuang APPROVED at the current heada STALE approval (approved an earlier head) never countsan APPROVED review from outside GOVERNED_APPROVERS never countsa later DISMISSED supersedes the same reviewer approvalthe hook agrees with the register about an exception-row candidate(+a cleared predicate verdict exits before any review is read)an ordinary diff enqueues freelygh pr merge N -R owner/repo·gh pr merge --auto --repo=... N·gh pr merge PR_URL·a REST PUT .../pulls/N/merge through curl·gh api -X PUT ...·reached through a separatorgrep -n "gh pr merge" AGENTS.md·echo "never gh pr merge a governed PR"a payload with no tool_name at all·an empty payload·an enqueue call naming no pull number·a non-numeric pull number·gh pr merge on the CURRENT branch (the PR is not named)the API cannot be read at all·the fail-open names where correctness still livesan exception-row path in a repo this container cannot resolveOS_ALLOW_GOVERNED_ENQUEUE=1 on the blocking case(no jq on PATH)The refusal message is asserted, not just the exit code: it must carry
approve BEFORE enqueue,does NOT re-run on a later approval, the head shathe approval has to pin, the governed hit, and the escape hatch.
Live end-to-end runs, against real PRs
Not just fixtures — the whole chain was exercised against the live API:
.claude/skills/pm-dispatch/references/contract-review.md,APPROVED by
os-zhuangat head6557bc1bb) → allowed, silently, which iscorrect. Same result through the
gh pr merge 14070Bash spelling.objectuicheck-driver-conformance.mjsFILTER_TEXT requirement-3 calls driver-mongodb's arm "the last place a barenew Errorescaped the ADR-0112 envelope" — it was not, and after #7047 the sentence names the wrong site #7159 (not governed) → allowed, no warning, so the sibling-repoREST read worked.
What went wrong once, recorded so it is not re-learned
The first revision of the self-test hard-coded
expect allowagainst.claude/workflows/docs-accuracy-audit.js, the one exception row that was cheapto lift without a generator toolchain. That row was retired upstream the same
day — the maintainer ruled that list off the governed surface entirely
(#13591, 「同意」) and its register row retired with it. The case then went red
over a register change the hook had nothing to do with.
The lesson is not "pick a more stable row". It is that a verdict copied out of
the register makes the matrix a second register — the exact shape the "⛔ do not
author a second mechanism" constraint exists to prevent, one level out. The case
now ASKS the register and requires the hook to answer the same way, whichever
way that is.
The prose half: STOPPED on a measured ceiling
The card also asks for one line in
landing-operations.md's 落地前检 carryingthe three elements, cross-stating the pure-regeneration exemption. It is not
fundable, and the floors are byte-exact rather than estimated:
check-skill-line-ratchetCEILINGS rowwrapLineat its own 120-byte cap reproduces the file exactly — the file is already optimally packedwrapLine漏判被队列守卫在 merge group 里拒收,白烧一轮队列), absorbable into the new sentence — under one line, so it funds 0 lines⇒ 4 lines needed, 0 lines available, and no dead text to delete. Deleting live
operational text to fund an annotation would be a worse trade than stopping.
⛔ No ceiling was raised: this card carries no authorizing ruling, and raising
one requires a maintainer ruling quoted in the raising PR.
What is NOT lost. The three elements are written in full, with their
provenance, in the hook's own header — which is where the house pattern already
puts a guard's provenance (
guard-shared-stash.shcarries its incident, itsalternatives and its exemptions the same way), and
.claude/hooks/deliberatelycarries no ceiling. The seat also now meets the rule at the point of use: the
refusal message states the order, the reason, and the remedy at the moment the
enqueue is attempted, which is strictly better placement than a line in a file
that has to be recalled. What the stop leaves open is the protocol text's own
cross-reference; that is an annotation, not a defense, which is precisely the
demotion the 方向修订 ordered.
Registration
.claude/settings.jsongains one PreToolUse entry matchingmcp__github__enable_pr_auto_merge|mcp__github__merge_pull_request, and thehook is appended to the existing
Bashchain. The self-test asserts theregistration exists and covers both MCP tool names — a hook that is not
registered guards nothing, and that failure is silent.
Sibling
The derivation-side half of this same guard —
dispatch-gates.mjslistingcheck-governed-queue-guardamong the LOCAL gates, where it can only ever exit1 outside a workflow run — is a separate card in another lane, #14004. It is not
addressed here and remains open; naming it so whoever implements either side
knows the other exists.
⛔ Also untouched, per the card's own limit: the queue guard's
dependency-install trade text, settled by its own card and only read here.
Verification
HEAD
aa49e8385. The derived union was re-run after the last commit.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsover the 3-path change set, all exit codes captured before any pipe:
check:doc-formula-expressions✓ … 9 @example(s) judged clean across 1095 packages/spec/src filescheck:agent-test-spelling✓ check-agent-test-spelling: 0 violations — 428 file(s)check:bash32-floor✓ … 26 tracked shell file(s) under scripts/**, .claude/hooks/**, .githooks/** name no bash 4+ constructcheck:doc-authoring✓ doc authoring guard: 14317 customer-facing string(s) … cleancheck:pm-governed-merges✓ check-governed-merges --self-test: 243 assertionscheck:skill-frame-sync✓ … 4 copies of the decision frame are structurally isomorphiccheck:nul-bytescheck-nul-bytes: OK (scanned 7729 text file(s) … no raw ASCII control bytes)check:pm-skill-ratchet✓ check-skill-line-ratchet: … headroom 0(added beyond the derived list: this PR makes a claim about that gate's numbers)check:doc-formula-expressionsfirst answered exit 3 — PREREQUISITE NOT MET,which its own text says is "not a finding" and measures nothing. Building its
dependency closure (
@objectstack/formula, then@objectstack/lint) turned itinto a real 0. Recorded because reading that 3 as a red gate is the mistake it
exists to prevent.
All five hook self-tests re-run as regression, 337 cases total:
guard-governed-enqueue49 passed, 0 failed (new)guard-main-checkout-bash121 passed, 0 failedguard-main-checkout87 passed, 0 failedguard-shared-stash44 passed, 0 failedguard-tree-enum36 passed, 0 failedChangeset: none — this publishes nothing from any package. The diff is three
.claude/**files.skip-changesetapplied.No
skills/**file is in this diff, so the published-catalog budget readingdoes not apply: the diff is
.claude/hooks/plus.claude/settings.json. Thepublished catalog appears only as a fixture path string inside the self-test.
Governed, and it walks its own rule
.claude/**⇒ this PR is governed ⇒ draft only, human merge, reviewrequested from the authorized approvers. This PR is itself the shape the guard
describes, and the guard refuses its own enqueue until an approval is pinned to
its current head.
Generated by Claude Code
Generated by Claude Code